iT邦幫忙

2023 iThome 鐵人賽

DAY 4
1

(No English version yet.)

上一篇我們介紹到Long Method(長方法)的特徵與成為不良氣味(Bad Smell)的原因,接下來我們將進入程式碼實作技巧環節,深入介紹有哪些重構技巧可以對應使用。首先來看看參考Joshua Kerievsky所製作的對照表,考慮到重構分類且能夠對應Long Method氣味的重構技巧清單:

  1. Composing Method
    1. Extract Method
    2. Inline Method
    3. Inline Temp
    4. Extract Variable (Introduce Explaining Variable)
    5. Replace Temp with Query
    6. Replace Method with Method Object
    7. Substitute Algorithm
  2. Simplifying Method Calls
    1. Introduce Parameter Object
    2. Preserve Whole Object
  3. Simplifying Conditional Expressions
    1. Decompose Conditional
    2. Replace Conditional with Polymorphism
    3. Replace Conditional Dispatcher with Command
  4. Moving Accumulation
    1. Move Accumulation to Collecting Parameter
    2. Move Accumulation to Visitor

這張清單與原始的對照表有若干出入,我會在介紹到各項細節時解釋我安排的原因。


Composing Method

關於這個重構類別,我主要參考來自三個不同出處,分別是Martin Fowler所著作的「Improving the Design of Existing Code」第六章節頁89「Composing Methods」、Joshua Kerievsky所著作之「Refactoring to Patterns」內頁62「Compose Method」一章、以及知名重構網站Refactoing Guru有關「Composing Methods」的介紹。雖然我會盡我所能地吸收、統整、消化後來呈現出自己的觀點,但我依然相當推薦各位對於「重構」這個主題有興趣的朋友,有機會都可以去查閱比較三處不同的原始版本介紹,或許也能夠獲得與我不同的啟發。

Compose Method是指微小精妙且易於理解閱讀的方法(method),而「Composing」是動名詞,描述以「Compose Method」為目標的一系列重構行為。可以留意Martin與Joshua分別在此處採用不同的命名方式,我的解讀是前者更在意過程中的技巧手法(Composing Methods),後者則更重視目標(Compose Method)。華文翻譯或可稱為「重新組織方法(函數)」或是「優化方法(函數)」,但我都不是很滿意,故華文翻譯暫且保留只留英文。

作為重構技巧的分類,Composing Methods之下共有九種圍繞在「方法」之上的重構技巧,但並非全部都是針對「Long Method」這種氣味。考慮到本文是聚焦在如何重構Long Method的對應技巧之上,這邊忍痛放棄Composing Methods的深度介紹與以下的所有重構技巧說明,只披露其中七種對應Long Method的手法。

Extract Method

多數時候當我們想要簡化Long Method時,第一個想到的便是Extract Method (抽出方法)。讓我們直接看範例:

void printBoard() {
  printBanner();

  // Print details.
  System.out.println("name: " + name);
  System.out.println("amount: " + amount);
}

當我們觀察到存在鄰近的程式碼區塊執行相似的任務,並且需要註解來輔助表示意圖,代表我們很有可能可以抽取出成為獨立的方法,並從原本的方法中引用。重構後的範例如下:

void printBoard() {
  printBanner();
  printDetails();
}

void printDetails() {
  System.out.println("name: " + name);
  System.out.println("amount: " + amount);
}

物件導向中其中一個很重要的概念是「封裝」。多數時候我們無需逐行去了解註解「Print details」下方每一行程式碼具體而言要做些什麼有沒有副作用與意外,我們只要單純去呼叫printDetails()便可,如此就可以省下不必要的閱讀理解時間。

Inline Method

如果一個方法的實作本身比起命名更能表達其意圖,直接使用實作來取代意味不明的方法呼叫。或許可以視為Extract Method的反向手法。

class PizzaDelivery {
  // ...
  int getRating() {
    return moreThanFiveLateDeliveries() ? 2 : 1;
  }
  boolean moreThanFiveLateDeliveries() {
    return numberOfLateDeliveries > 5;
  }
}

numberOfLateDeliveries()在這個範例中,並沒有比起實作帶來更多資訊,對於多數開發者來說,直接看程式碼實作反而更好理解。因此當我們觀察到這樣的氣味,可以採取以下作法:

class PizzaDelivery {
  // ...
  int getRating() {
    return numberOfLateDeliveries > 5 ? 2 : 1;
  }
}

當我們省略冗余的額外方法呼叫,改而採用能夠更直觀理解的表示時,我們就成功地讓方法變得比原來更短、更整潔。我們必須小心避免讓方法只是另外一個方法的傳聲筒,而其中卻沒有包含任何額外意圖。

Inline Temp

如果你在方法內發現有一個臨時變數(temporary variable)的功用僅僅只是表達自身而沒有其他邏輯包括在其中,或許我們可以考慮將其省略,這樣的重構手法稱之為 Inline Temp。

boolean hasDiscount(Order order) {
  double basePrice = order.basePrice();
  return basePrice > 1000;
}

如範例所示,basePrice的唯一功用就是暫存order.basePrice()回傳的數值。在這樣的情境中,我們可以直接取用order.basePrice(),請見以下重構後的結果:

boolean hasDiscount(Order order) {
  return order.basePrice() > 1000;
}

是不是更短更簡潔了呢?

Extract Variable (Introduce Explaining Variable)

Extract Variable(抽出變數)也可稱呼為 Introduce Explaining Variable(引入解釋性參數),我認為恰好與 Inline Temp手法相反,主要是為了提升可讀性而存在。這個技巧與我們首先介紹的 Extract Method相當類似,只是抽出的對象分別是方法與變數的不同。

void renderBanner() {
  if ((platform.toUpperCase().indexOf("MAC") > -1) &&
       (browser.toUpperCase().indexOf("IE") > -1) &&
        wasInitialized() && resize > 0 )
  {
    // do something
  }
}

在範例中我們可以見到if條件句中存在相當冗長的判斷,請注意當我們討論 Long Method氣味時,除了垂直方向程式碼行數的長度外,當然也需要留意水平方向每行程式碼的長度。合宜的程式碼長度並沒有統一固定的標準,過去通常會建議在一個螢幕寬,但隨著螢幕越做越寬到可以分割畫面的水準,我認為螢幕寬已經不是放諸四海皆準的標準。在Ruby中通常會建議每行不要超過150的字元,我認為大方向來說,同一行程式碼所包括的概念越少越好。

在這個範例中,我們可以作以下的重構:

void renderBanner() {
  final boolean isMacOs = platform.toUpperCase().indexOf("MAC") > -1;
  final boolean isIE = browser.toUpperCase().indexOf("IE") > -1;
  final boolean wasResized = resize > 0;

  if (isMacOs && isIE && wasInitialized() && wasResized) {
    // do something
  }
}

如你所見,雖然renderBanner()方法的行數比起重構之前更多,但關鍵意義上的邏輯判斷,if判斷句內的isMacOs && isIE && wasInitialized() && wasResized變得更容易理解也一目瞭然。隨著相似的程式碼片段出現,後續這些元素也可以考慮 Extract Method 抽出為獨立的方法進行共用,來做到下一步更簡潔的瘦身效果。

Replace Temp with Query

當你發現存在一個臨時變數用來接住查詢結果,我們可以考慮將之抽出為獨立的查詢方法。

double calculateTotal() {
  double basePrice = quantity * itemPrice;
  if (basePrice > 1000) {
    return basePrice * 0.95;
  }
  else {
    return basePrice * 0.98;
  }
}

觀察上述的範例,我們發現basePrice除了用來判斷是否大於一千外,還用做計算新價格的基準。這其中並沒有重複給值,因此我們可以作以下重構:

double calculateTotal() {
  if (basePrice() > 1000) {
    return basePrice() * 0.95;
  }
  else {
    return basePrice() * 0.98;
  }
}
double basePrice() {
  return quantity * itemPrice;
}

這樣的技巧與 Extract Method 有些接近,兩者的差異在於抽出的是一個臨時變數,並且抽出為查詢方法(Query Method)。

Replace Method with Method Object

這是一個相對進階的重構技巧,需要具備對物件導向的理解才有辦法實作,將方法用新的方法物件取代。

舉例來說當你有一個方法內包含許多高度綁定的區域變數(local variables),讓你無法輕易地使用 Extract Method 抽出方法來進行重構時,我們可以先將這些區域變數打包為一個全新的類別,接著在原方法內宣告一個新類別的實體,這樣就可以將複雜冗長的實作搬移到新類別當中。

讓我們看看範例程式:

class Order {
  // ...
  public double price() {
    double primaryBasePrice;
    double secondaryBasePrice;
    double tertiaryBasePrice;
    // Perform long computation.
  }
}

如你所見,price()方法內包含了許多區域變數如primaryBasePrice等,這會讓抽出處理變得困難。此時我們可以透過一個新的類別(Class)來進行封裝。

class Order {
  // ...
  public double price() {
    return new PriceCalculator(this).compute();
  }
}

class PriceCalculator {
  private double primaryBasePrice;
  private double secondaryBasePrice;
  private double tertiaryBasePrice;
  
  public PriceCalculator(Order order) {
    // Copy relevant information from the
    // order object.
  }
  
  public double compute() {
    // Perform long computation.
  }
}

請將焦點放在我們所要處理的核心部位,也就是price()之上。將訂單(order)物件自身作為參數,宣告一個新的價格計算類別(class PriceCalculator)的實體後,我們可以將原本複雜扭曲的邏輯輕鬆用一行表示為new PriceCalculator(this).compute(),簡直如同魔法一樣神奇。

當然可能會有人懷疑表示,新增的價格計算類別可能會反而使得專案程式碼總行數增加,但是這樣一個將長方法(Long Method)拆解為Compose Method與另一個中等尺寸類別的行為,正是我們此處重構技法的核心精神:化大為小,一分為二

Substitute Algorithm

當你發現在方法內存在複雜的演算(complicated algorithm),我們可以考慮將其用更小更簡潔的演算來取代。具體實作方式可能因為不同的程式語言而有很大的差異,但此處我以Java作為示範:

String foundPerson(String[] people){
  for (int i = 0; i < people.length; i++) {
    if (people[i].equals("Don")){
      return "Don";
    }
    if (people[i].equals("John")){
      return "John";
    }
    if (people[i].equals("Kent")){
      return "Kent";
    }
  }
  return "";
}

這個範例是在迴圈內存在多個沒有效率的if判斷式進行字串比對,熟悉Java或平時有在刷題的朋友,或許能很快找到更好更短的表達,譬如以下的重構:

String foundPerson(String[] people){
  List candidates = Arrays.asList(new String[] {"Don", "John", "Kent"});
  for (int i=0; i < people.length; i++) {
    if (candidates.contains(people[i])) {
      return people[i];
    }
  }
  return "";
}

我們使用Arrays.asList創造出一個包括姓名的List,並且使用contains來比對字串,取代原本幾乎是寫死的if判斷式。這樣的好處是便於擴充,當有新的人名出現時,只需要改動candidates,而無需增加一個新的if判斷子句。我必須強調這樣的範例只能示範 Substitute Algorithm 的一小部分,同時如何在不同語言內實作優化演算法表示,還請自行努力。

Simplifying Method Calls

根據Refactoring Guru網站的資料,Simplifying Method Calls之下共有14種重構技法。但此處依照對照表,我們能夠對應到 Long Method 氣味的只有其中兩種。一樣礙於篇幅請恕我省略其餘12種重構技巧與 Simplifying Method Calls的深入頗析。

Simplifying Method Calls 顧名思義是企圖簡化方法的呼叫,而在Martin Fowler所著作的「Improving the Design of Existing Code」一書中,第十章(頁 220)則以「Making Method Calls Simper」稱呼之,我認為兩者只是可以省略的命名差異。只是我更偏愛簡短的版本來符合避免 Bloster 氣味的精神,所以省略多餘的動名詞(Making)。

Simplifying Method Calls 與 Composing Method 相似的地方在於都是聚焦在優化方法(method)之上,只是前者會更限縮於圍繞在「方法呼叫」(Method Calls),而後者則是方法本身。

Introduce Parameter Object

將方法中過長的呼叫參數轉化整合成為單一物件。這個重構方法與另一個我們在後面幾天也會介紹到一樣屬於 Bloaters 的程式碼氣味 Data Clumps (資料團塊)高度相關,但後者的氣味泛指任何資料團塊而不限縮於方法中的參數。

最常見的例子就是開始與結束日期,例如我們範例如下:

function amountInvoiced(startDate, endDate) {...}
function amountReceived(startDate, endDate) {...}
function amountOverdue(startDate, endDate) {...}

實際上開始與結束日期總是成對出現而且缺一不可,所以可以重構如下:

function amountInvoiced(aDateRange) {...}
function amountReceived(aDateRange) {...}
function amountOverdue(aDateRange) {...}

如此一來,方法的取用跟呼叫就變得更簡單,也可以略為提升程式碼閱讀速度。

Preserve Whole Object

當你從不同的物件中取得數值並傳入方法中作為參數使用,我們可以直接將物件本身傳入。

int low = daysTempRange.getLow();
int high = daysTempRange.getHigh();
boolean withinPlan = plan.withinRange(low, high);

從範例中我們可以發現,lowhigh都是從daysTempRange物件取得的值。所以實際上我們並不需要分別作為參數傳入,可以重構如下:

boolean withinPlan = plan.withinRange(daysTempRange);

此處省略了示範如何修改withinRange(low, high)withinRange(daysTempRange)的過程,但這只是變更呼叫getLow()getHigh()的位置,我相信懂得就懂無須多言。

Simplifying Conditional Expressions

當我們發現有過於複雜的條件判斷存在時,最好考慮將其適度簡化。與多數重構分類相同,在這個分類下一共有8種不同的重構技巧,但此處我們只優先討論符合 Long Method 長方法氣味相符的其中三種。

Decompose Conditional

當你發現存在複雜的條件判斷邏輯(不管是if判斷式或是switch判斷式),我們可以嘗試將其拆解包裝成方法,並且引用。

if (date.before(SUMMER_START) || date.after(SUMMER_END)) {
  charge = quantity * winterRate + winterServiceCharge;
}
else {
  charge = quantity * summerRate;
}

例如我們有一段判斷目前是否為夏天,並據此有不同計算邏輯的條件判斷式,在物件導向的世界裡,我們鼓勵將複雜的實作包裝起來, 眼不見為淨 能夠更聚焦於當下的任務。

if (isSummer(date)) {
  charge = summerCharge(quantity);
}
else {
  charge = notSummerCharge(quantity);
}

即使是最初階的開發者,只要略懂英文,都可以輕鬆讀出重構後的意圖。當目前日期(date)是夏天時,則以夏日費率計價;當非夏天時,則以非夏日費率計價。這樣的邏輯簡單清楚到讓說明或註解都顯得多餘,就是我們理想中讓程式碼自身表達意圖的效果,而不需要額外依賴註解、文件、測試來讓開發者明白商業邏輯。好的程式碼應該解釋自身。

Replace Conditional with Polymorphism

對照表上稱為「Replace Conditional Logic with Strategy」,但在Joshua Kerievsky的書中(頁 22),他稱為「Replace Conditional Calculations with Strategy」;而在 Refactoring GuruRefactoing.com網站上,則是被稱為「Replace Conditional with Polymorphism」,我個人認為後者的稱呼更為貼切。這些名稱差異讓我困惑了一下,差一點誤以為是不同技巧。

簡言之,當我們發現存在複雜冗長的條件判斷,而判斷基準是物件本身的屬性時,我們可以考慮物件導向中的「多形」(Polymorphism)來取代條件判斷。這樣的手法則是被Joshua稱呼為 Strategy Object ,但在實作上是相同的。

直接看範例會更容易理解:

class Bird {
  // ...
  double getSpeed() {
    switch (type) {
      case EUROPEAN:
        return getBaseSpeed();
      case AFRICAN:
        return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
      case NORWEGIAN_BLUE:
        return (isNailed) ? 0 : getBaseSpeed(voltage);
    }
    throw new RuntimeException("Should be unreachable");
  }
}

假設我們有一個鳥的類別,而不同物件實體在歐洲與非洲等不同條件下存在不同速度。這樣的條件判斷很容易出現在任何專案當中,而且看上去運作良好,開發者很可能會誤以為這樣的程式碼已經「足夠」良好,但是我們依然有技巧可能嘗試簡化,請看重構後的範例:

abstract class Bird {
  // ...
  abstract double getSpeed();
}

class European extends Bird {
  double getSpeed() {
    return getBaseSpeed();
  }
}
class African extends Bird {
  double getSpeed() {
    return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
  }
}
class NorwegianBlue extends Bird {
  double getSpeed() {
    return (isNailed) ? 0 : getBaseSpeed(voltage);
  }
}

// Somewhere in client code
speed = bird.getSpeed();

當我們為了不同屬性的鳥創造出更多不同行為的子類別,然後共同繼承鳥的父類別,透過物件導向子類別可以改寫父類別行為的方式,來省略複雜的條件判斷。當我們手上有一個鳥的實體,只需要簡單呼叫bird.getSpeed(),就可以依據物件當前的子類別是EuropeanAfrican,而回傳不同邏輯。

這種技巧通常不會在專案或功能初始就被採用,而是隨著時間逐年複雜度越來越高之後,會發現分離出更多的子類別是管理邏輯的適切技巧。

Replace Conditional Dispatcher with Command

這個重構技巧與上一個類似,但不同之處是將複雜的邏輯以 Command objects 取而代之。讓我們直接看範例:

public class RequestProcessor {
  public void processRequest(String requestType, String data) {
    if ("Add".equals(requestType)) {
      // Logic for adding data
    } else if ("Update".equals(requestType)) {
      // Logic for updating data
    } else if ("Delete".equals(requestType)) {
      // Logic for deleting data
    } else {
      throw new IllegalArgumentException("Invalid request type");
    }
  }
}

當我們有複雜的邏輯判斷字串requestType去實作不同的命令時,我們可以把每一個命令任務的實體抽出來為一個命令類別的物件(Command Object)。

interface Command {
  void execute(String data);
}

class AddCommand implements Command {
  @Override
  public void execute(String data) {
    // Logic for adding data
  }
}

class UpdateCommand implements Command {
  @Override
  public void execute(String data) {
    // Logic for updating data
  }
}

class DeleteCommand implements Command {
  @Override
  public void execute(String data) {
    // Logic for deleting data
  }
}

當各種命令的類別準備好以後,我們回到原有的程式碼,呼叫我們剛剛創造的命令物件。

import java.util.HashMap;
import java.util.Map;

public class RequestProcessor {
  private final Map<String, Command> commandMap = new HashMap<>();

  public RequestProcessor() {
    commandMap.put("Add", new AddCommand());
    commandMap.put("Update", new UpdateCommand());
    commandMap.put("Delete", new DeleteCommand());
  }

  public void processRequest(String requestType, String data) {
    Command command = commandMap.get(requestType);
    if (command != null) {
      command.execute(data);
    } else {
      throw new IllegalArgumentException("Invalid request type");
    }
  }
}

請注意command的類別會由commandMap.get(requestType)給定,因此我們只需要簡單呼叫command.execute(data),便可以確保資料data有傳入正確的命令類別中執行。

Moving Accumulation

我無法在現有的資料中找到這個重構手法的分類,但又發現有兩種相近的手法。如同大多數開發者的習慣,當我們發現缺少什麼時,就自己創建一個,所以全新的重構分類「Moving Accumulation」誕生了!如果有朋友發現更好的分類方式或參考資料,歡迎與我分享。

Move Accumulation to Collecting Parameter

這個重構手法也可見於Joshua 書中的同名章節(頁 55),但卻無法在 Refactoing Guru 與 Refactoring.com 網站上找到。在查找資料時,真是深切感受到命名不統一的困擾。

實作上可能因為不同程式語言會有差異,但基本概念是將準備回傳的集合成果收集為參數傳入,而不是直接在迴圈中累加。讓我們看範例:

public class SumOfEvenNumbers {
  public static void main(String[] args) {
    int[] numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    int sum = 0;

    for (int num : numbers) {
      if (num % 2 == 0) {
        sum += num;
      }
    }

    System.out.println("Sum of even numbers: " + sum);
  }
}

整數sum在迴圈中的功能很單純,就是合計數字集合int[] numbers中所有符合偶數的總和。這樣的寫法固然正確回傳我們所要的結果,但可能導致可重新取用性差以及不好維護的問題。我們可以考慮以下的重構:

import java.util.ArrayList;
import java.util.List;

public class SumOfEvenNumbers {
  public static void main(String[] args) {
    int[] numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    List<Integer> evenNumbers = new ArrayList<>();

    for (int num : numbers) {
      if (num % 2 == 0) {
        evenNumbers.add(num);
      }
    }

    int sum = 0;
    for (int evenNum : evenNumbers) {
      sum += evenNum;
    }

    System.out.println("Sum of even numbers: " + sum);
  }
}

在重構範例中,我們新增了一個集合evenNumbers去搜集每一個偶數,然後簡單的合計總和得到一樣的結果。許多朋友看到這裡可能會質疑,即使是在範例中,方法也因此變得更長,怎麼可以算是 Long Method 的對應重構技巧呢?

我們可以將這個範例視為一個重構的中間過程,事實上,當邏輯更清晰以後,我們有許多部分是可以進一步抽出來簡化,這部分可以參考上面所提及的技巧。即使只是單純考慮行數,在重構的過程中暫時增長是非常正常且自然的事情,正如同Sandi曾經在不同的分享中也提及「清楚明白地重複也好過糟糕的抽象化」(Duplication is cheaper than wrong obstraction)。在尚未清楚類別抽出方向時,先讓相似的程式碼靠近排列,是一種實務上很常使用的技巧。

Move Accumulation to Visitor

這個重構技巧與我們剛剛介紹的方法高度相似,但是把資料搬移到Visitor Object(Visitor Pattern)。

訪問者模式(Visitor Pattern)是指一種設計模式,可以傳入操作或命令,但卻不需要改變其資料結構或修改物件本身。核心概念是把資料結構與物件本體和操作(operation)或命令(command)分離開來。

我必須坦白承認,現階段的我尚且無法完全理解這個重構技巧。所以比起半桶水的囫圇吞棗,我決定果斷放棄,並且推薦網友可以參照 Joshua 所開設的顧問公司 Industrial Logic 官網上的介紹來進行了解。


當我終於完成有關Long Method的所有重構手法介紹,字數來到驚人的15000字,這應該是我個人創下有史以來的新紀錄。當然其中多數是範例程式碼,但依舊花費了我假日一整天的時間。所幸,並不是多數的氣味都能夠對應到如此多的重構技巧,否則這次鐵人賽的挑戰難度將會翻上一倍。

一樣感謝各位讀者的陪伴與閱讀,如果內容中發現疏漏或是不同觀點,也歡迎下方留言告知,非常感謝。


上一篇
Bloaters > Long Method 過長的方法
下一篇
Bloaters > Large Class 大類別
系列文
程式碼氣味到重構之路 Code Smells to Refactorings37
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

0
Bater
iT邦新手 4 級 ‧ 2023-09-04 22:02:00

根據Industrial Logic對於Compose Method的說明,我文章內對於Joshua的理解可能有誤。
https://www.industriallogic.com/xp/refactoring/composeMethod.html

0
Bater
iT邦新手 4 級 ‧ 2023-09-12 20:05:58

對照表上本來就有「Replace Conditional with Polymorphism」,所以「Replace Conditional Calculations with Strategy」或「Replace Conditional Logic with Strategy」可能是相似但不同的手法嗎?

我要留言

立即登入留言